開放封閉原則(Open-Closed Principle)
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
--
軟體中的類別、模組、函式等等應該開放擴充,但是封閉修改。
白話版本為:
當系統需要擴充功能時,應該藉由 增加新的程式碼
來擴充系統的功能,而 不是藉由修改原本已經存在的程式碼
來擴充系統的功能。
開放封閉原則為軟體開發的 首要原則,很多軟體開發原則都是建構在這短短一句話之上,因此可以通過此原則引伸出其他原則。很多時候一個程式具有良好的設計,往往說明它是符合開放封閉原則。
隔離業務邏輯與附加邏輯,使業務邏輯更易於擴充,以便因應需求變化。
一個系統總有幾個極具價值的核心邏輯,這些核心邏輯實現了企業或專案的業務規則(Business Rule)與 Know How。通常可以從核心邏輯延伸出更多功能,提供使用者的便利性,以下將這些核心業務邏輯簡稱為「業務邏輯」。也就是說系統中有可能 20% 是業務邏輯,剩下的 80% 是圍繞著業務邏輯延伸出來的附加邏輯。
舉例來說,一個診所掛號系統一開始只有「掛號與叫號」功能。但若需要的話,也可以延伸出「叫號時發送簡訊提醒患者」功能。掛號系統的案例中業務邏輯是「掛號與叫號」;而「叫號時發送簡訊提醒患者」則是 隨著時間與新需求延伸出來的附加邏輯。
和軟體複雜的特質 軟體熵(Software entropy) 有關,指系統在經過修改後,程式碼的無序程度(意圖流失程度)與複雜程度皆會上昇。
需求變更和除錯是系統修改的主因,系統會隨著時間不斷衍生出新需求。這些需求可能是工程浩大的新功能;也可能是為了某個特定案例只使用一次的需求。甚至客戶往往在看見實際功能後,才想到有更好的解決方案或缺少哪些細項。於是剛釋出的功能馬上又進入重工(Rework)階段。
若開發人員不懂得將業務邏輯與附加邏輯分開,往往為了完成新需求,把附加邏輯寫在業務邏輯裡面,替業務邏輯擴充行為。這種做法一但遇到需求不停出現時,業務邏輯 與 附加邏輯 會漸漸地糊在一起變成一個大泥團導致程式脆弱化。新增需求和除錯更容易引入新的 Bug,解決新的 Bug 又引入更新的 Bug...。
(圖一)中的程式碼在專案中隨處可見,當 附加邏輯 與 業務邏輯 耦合在一起時,業務邏輯 會變得很難除錯、重複使用以及擴充,這些因素都會拉長開發時程,增加維護系統的成本。
因此開發人員應該要有個認知:
雖然需求並不是程式設計環節能控制的,但是程式碼應該要能夠適應快速多變的需求。
業務邏輯本身只需要關心業務規則(Business Rule),不應該和附加邏輯耦合在一起。一定要隔離業務邏輯與附加邏輯,才能確保業務邏輯的彈性。一旦業務邏輯有了彈性,程式就較容易面對需求變化。
新需求不斷出現,修改業務邏輯來擴充附加功能卻會促進 軟體熵 成長,增加維護系統的困難度。為了避免 軟體熵 的問題,開放封閉原則指導開發人員在面對需求變化時應該要:
盡可能減少對既有程式碼的修改,並開放擴充點,讓新需求可以從外部擴充業務邏輯。
實際上 開放封閉原則的設計思維 早在物件導向技術出現之前就存在,並且被廣泛應用在各種層面,從程式設計乃至框架、系統層級:
透過 $.ajax 的 done
, fail
, always
等公開函式從外部注入閉包,擴充 $.ajax 行為:
$.ajax({
method: "POST",
url: "some.php",
data: { name: "John", location: "Boston" }
})
.done(function() {
alert("success");
})
.fail(function() {
alert("error");
})
.always(function() {
alert("complete");
});
透過繼承 MVC 框架內建的 Controller
類別,擴充 Controller 層的行為:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
class HelloController extends Controller
{
public function index(Request $request){
return 'Hello World!';
}
}
透過繼承 React.Component
類別,擴充 Component 的行為:
class Welcome extends React.Component {
render() {
return <h1>Hello, {this.props.name}</h1>;
}
}
其他範例:
上述這些耳熟能詳的範例中,每個技術都被應用到成千上萬個不同的需求。這些高彈性技術的共通點是:至少有一個開放的擴充點,讓開發人員可以寫入自己的邏輯來完成功能。
開放封閉原則 讓開發人員不需要修改已經造好的輪子,就可以完成自己所需的功能。
這也是為什麼軟體技術能夠以海量增長的原因。但是開放封閉原則的原理是什麼呢?
解除耦合的方法,就是讓程式碼不知道彼此的存在。
程式碼可以透過繼承、引入介面或注入閉包等技術,讓附加邏輯可以”共用公開的介面“。業務邏輯在需要擴充的時機,則須透過 統一的公開介面 來調用附加邏輯。
這其實是利用 多型的特性,在業務邏輯和附加邏輯之間引入一個抽象(繼承、介面、閉包等):
開發人員必須懂得如何找出業務邏輯與附加邏輯的邊界,才能從中開放擴充點引入抽象隔離彼此。
簡單有效的方法是,把重要與不重要的事情分開。例如 UI 介面所需的邏輯與業務規則無關,所以它們之間應該要有一個邊界。也可以 已變化為軸的地方 繪製邊界,邊界另一側的元件將以不同的速率以及不同的原因改變:
說到底,其實一直都是 單一職責原則 指導我們應該如何切割邊界。
引入抽象後,業務邏輯與附加邏輯 只能透過抽象介面與彼此互動。如此一來,業務邏輯可以專注於本身的業務規則(Business Rule),而附加邏輯則可以隨時被多個不同的實作替換掉,並且業務邏輯完全不需要關心這些事。
一但建立起開放封閉原則的架構(圖四),就能擁有一個安全的防火牆。程式碼之間的變動不會傳播出去。附加邏輯的變動不會影響到業務邏輯。
事實上,軟體開發技術的歷史就是「如何方便地建立 Plugin 來奠定可擴展和可維護的系統架構」的故事 - Uncle Bob. 《Clean Architecture》
從原理中可以發現,開放封閉原則能夠解除業務邏輯與附加邏輯之間的耦合,並且保持業務邏輯的彈性。接下來將透過一個「每日信件功能」的案例,講解如何讓開放封閉原則落地。
某校園系統中,有一個寄信排程會在每天凌晨寄送「每日信件」,最初的需求為:
class Send_today_mail extends MX_Controller
{
public function index()
{
/** 1. 撈取信件的內容,並產生信件 HTML */
// 取得所有使用者昨天收到的系統通知
$system_notifies = $this->notify_api->get_yesterday_notify();
// 依照收件者的 email 分群通知訊息
$system_notifies = $this->group_system_notify_by_email($system_notifies);
// 產生信件 HTML 內容
$mail_contents = $this->make_mail_contents($system_notifies);
/** 2. 寄送信件 */
$this->send_mail($mail_contents);
}
/** 建立系統通知信件 */
private function get_yesterday_notify() {/** ... */}
private function group_system_notify_by_email($system_notifies) {/** ... */}
private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */}
private function make_mail_contents($system_notifies){/** ... */}
private function send_mail($mail_contents) {/** ... */}
}
第一版本的程式碼中可以看見寄信功能主要分兩個部分:
Send_today_mail 的最初版本中,總共只有 93 行程式碼。
class Send_today_mail extends MX_Controller
{
public function index()
{
/** 1. 撈取信件的內容,並產生信件 HTML */
// 取得所有使用者昨天收到的系統通知
$system_notifies = $this->notify_api->get_yesterday_notify();
// 依照收件者的 email 分群通知訊息
$system_notifies = $this->group_system_notify_by_email($system_notifies);
// 取得 Messenger 使用者、對話群組 id
list($message_users, $group_ids) = $this->message_api->get_all_message_users();
// 取得昨日的 Messages
$messages = $this->get_yesterday_message($group_ids);
// 產生信件 HTML 內容
$mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users);
/** 2. 寄送信件 */
$this->send_mail($mail_contents);
}
/** 建立系統通知信件 */
private function get_yesterday_notify() {/** ... */}
private function group_system_notify_by_email($system_notifies) {/** ... */}
private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */}
/** 建立 Messenger 訊息信件 */
private function get_yesterday_message() {/** ... */}
private function message_filter($messages, $group_id) {/** ... */}
private function make_message_template_variables($messages, $message_users, $tplVar) {/** ... */}
/** 合併信件內容並寄送信件 */
private function make_mail_contents($system_notifies, $messages, $message_users){/** ... */}
private function send_mail($mail_contents) {/** ... */}
}
第二版本加入了新需求,Send_today_mail 的程式碼一下子從 93 行增加到 295 行。為了產生 系統通知 和 Messages 的信件 HTML 內容,make_mail_contents()
函式已經開始出現耦合。
class Send_today_mail extends MX_Controller
{
public function index()
{
/** 1. 撈取信件的內容,並產生信件 HTML */
// 取得所有使用者昨天收到的系統通知
$system_notifies = $this->notify_api->get_yesterday_notify();
// 依照收件者的 email 分群通知訊息
$system_notifies = $this->group_system_notify_by_email($system_notifies);
// 取得 Messenger 使用者、對話群組 id
list($message_users, $group_ids) = $this->message_api->get_all_message_users();
// 取得昨日的 Messages
$messages = $this->message_api->get_yesterday_message($group_ids);
// 取得明日的課程資訊
$tomorrow_course = $this->get_tomorrow_course();
// 取得課程教師資訊
$course_ids = array_column($tomorrow_course, 'course_id');
$teachers = $this->course_api->get_course_teachers($course_ids);
// 產生信件 HTML 內容
$mail_contents = $this->make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers);
/** 2. 寄送信件 */
$this->send_mail($mail_contents);
}
/** 建立系統通知信件 */
private function get_yesterday_notify() {/** ... */}
private function group_system_notify_by_email($system_notifies) {/** ... */}
private function make_notifies_template_variables($notifies, $tplVar = array()) {/** ... */}
/** 建立 Messenger 訊息信件 */
private function get_yesterday_message() {/** ... */}
private function message_filter($messages, $group_id) {/** ... */}
private function make_message_template_variables($messages, $message_users, $tplVar) {/** ... */}
/** 建立 明日課程 信件 */
private function get_tomorrow_course() {/** ... */}
private function get_course_teachers(course_ids) {/** ... */}
private function make_course_start_template_variables() {/** ... */}
/** 合併信件內容並寄送信件 */
private function make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers){/** ... */}
private function send_mail($mail_contents) {/** ... */}
}
第三個版本,Send_today_mail 的總行數來到 504 行,make_mail_contents()
函式的耦合更加嚴重。
到目前為止,Send_today_mail 已經變得不太容易維護,這個 Controller 裡面包含了 12 個函式,其中好幾個函式卻都是在做一樣的事情:「撈取信件的內容,並產生信件 HTML」。
為了避免 Send_today_mail 因新需求的出現不斷膨脹,接下來將開始替 Send_today_mail 進行一次重構。這次重構的目的將是引入抽象,拆散 隨著時間增加的附加邏輯。
class Send_today_mail extends MX_Controller
{
/**
* 寄送系統每日收到的所有通知訊息
*/
public function index()
{
/** 1. 撈取信件的內容,並產生信件 HTML */
$email_maker = new Today_email_maker();
$email_maker->add_handler(new System_notify_handler());
$email_maker->add_handler(new Message_handler());
$email_maker->add_handler(new Course_start_handler());
$mail_contents = $email_maker->make_mail_contents();
/** 2. 寄送信件 */
$this->send_mail($email_contents);
}
private function send_mail($mail_contents) {/** ... */}
}
上面是重構後的結果,Send_today_mail 的程式碼大幅減少,可讀性也有提高。
這樣拆分職責的邏輯是「已變化為軸的地方劃分界限」:
Send_today_mail 從第一次發佈以來就一直新增 信件種類,這些 信件種類 最後都需要透過 make_mail_contents()
產生信件內容。那麼隨著新需求冒出來的信件種類,就是容易變動的地方,也就是 附加邏輯;負責產生信件 HTML 內容的 make_mail_contents()
則是在流程中不變的邏輯,故可視為 業務邏輯。
找出 業務邏輯 與 附加邏輯 後,即可將邏輯拆分成下面結構:
make_mail_contents()
搬移至 Today_email_maker
類別。System_notify_handler
Message_handler
Course_start_handler
具體細節如下:
在(圖五)結構圖中可以看見業務邏輯和附加邏輯之間引入一個抽象介面(Daily_email)。業務邏輯 透過公開 add_handler(Daily_email $handler)
函式,讓 Controller 層可以從外部注入 附加邏輯。附加邏輯則須按照 Daily_email 介面的定義,實作完成新需求所需的程式碼。
這是利用多型的特性,讓 add_handler(Daily_email $handler)
可以接收任何有實作 Daily_email 介面的物件。這也是為什麼 Controller 層可以對 Today_email_maker
注入多個附加邏輯類別的原因。
下面附上重構後的範例程式碼:
interface Daily_email
{
/** 取得今日信件內容 */
public function get_email_content();
/** 建立 Email HTML 樣板變數 */
public function make_email_template_variables();
/** 建立 Email HTML 內容 */
public function make_email_content();
}
class Today_email_maker
{
/** @var Daily_email[] */
private $handlers = array();
public function add_handler(Daily_email $handler)
{
array_push($this->handlers, $handler);
}
public function make_mail_contents()
{
$mail_contents = array();
foreach ($this->handlers as $handler) {
$handler->get_email_content();
$handler->make_email_template_variables();
array_push($mail_contents, $handler->make_email_content());
}
return $mail_contents;
}
}
附加邏輯如下:
class System_notify_handler implements Daily_email
{
public function get_email_content() { /** ... */}
public function make_email_template_variables() { /** ... */}
public function make_email_content() { /** ... */}
private function xxxx() { /** ... */}
/** ... */
}
class Message_handler implements Daily_email
{
public function get_email_content() { /** ... */}
public function make_email_template_variables() { /** ... */}
public function make_email_content() { /** ... */}
private function xxxx() { /** ... */}
/** ... */
}
class Course_start_handler implements Daily_email
{
public function get_email_content() { /** ... */}
public function make_email_template_variables() { /** ... */}
public function make_email_content() { /** ... */}
private function xxxx() { /** ... */}
/** ... */
}
重構前,只要每新增一種信件,make_email_content
就會耦合新的信件種類資料,以便產生信件 HTML 內容。
/** 重構前 Send_today_mail.php */
private function make_mail_contents($system_notifies, $messages, $message_users, $tomorrow_course, $teachers){/** ... */}
{
// 建立 Notifies 信件樣板變數
$tplVar = $this->make_notifies_template_variables($notifies);
// 建立 Messages 信件樣板變數
$tplVar = $this->make_message_template_variables($messages, $message_users, $tplVar);
// 建立 明日課程 信件樣板變數
$tplVar = $this->make_tomorrow_course_template_variables($tomorrow_course, $teachers, $tplVar);
// 建立信件樣板
$mail_contents = [];
foreach ($tplVar as $target_mail => $template_data) {
// 以使用者的 email 做區隔
$mail_contents[$target_mail] = $this->load->view('send_today_notify_mail/mail_template', $template_data, true);
}
return $mail_contents;
}
重構後,不管再新增多少種類的信件,Today_email_maker
都不需修改任何程式碼(封閉修改)。只需新增實作 Daily_email 介面的附加邏輯即可完成新需求(開放擴充)。而且還可以隨時移除任何一種信件種類。這就是利用開放封閉原則的成果,讓程式碼可以適應需求變化。
/** 重構後 Today_email_maker.php */
public function add_handler(Daily_email $handler)
{
array_push($this->handlers, $handler);
}
public function make_mail_contents()
{
$mail_contents = array();
foreach ($this->handlers as $handler) {
$handler->get_email_content();
$handler->make_email_template_variables();
array_push($mail_contents, $handler->make_email_content());
}
return $mail_contents;
}
你可能已經發現了,引入抽象後程式碼變得比重構前還要複雜。若每個新功能都要符合開放封閉原則,系統結構會變得極其複雜,而且還會有很多抽象沒有實質效益。
因此 Uncle Bob 建議可以接受不合理的程式碼帶來的第一次愚弄。在最初寫程式的時候,可以先假設變化永遠不會發生,這有利於我們迅速完成需求。當變化發生並且對我們接下來的工作造成影響的時候,再回過頭來封裝這些變化的地方。確保未來不會掉進同一個坑里。
在寫程式的時候,可以把開放封閉原則當作目標,因為設計良好的程式通常都經得起開放封閉原則的考驗。也有人說設計模式就是幫良好的設計取個名字,因為設計模式幾乎都是遵守開放封閉原則的。開放封閉原則延伸出單一職責原則、依賴倒置原則等其他設計原則,其實都只是為了完成開放封閉原則這個目標的過程。
開放封閉原則是終極目標,很少人可以百分之百做到,但只要朝著原則的方向努力,就可以不斷改善系統的架構,讓程式碼可以“擁抱變化“。